Skip to content

feat(parser): add Shelley (exe.dev) agent support - #718

Merged
wesm merged 15 commits into
kenn-io:mainfrom
mjacobs:feat/shelley
Jun 17, 2026
Merged

feat(parser): add Shelley (exe.dev) agent support#718
wesm merged 15 commits into
kenn-io:mainfrom
mjacobs:feat/shelley

Conversation

@mjacobs

@mjacobs mjacobs commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Adds first-class support for Shelley, the exe.dev / boldsoftware coding agent (github.com/boldsoftware/shelley), so its sessions appear alongside every other agent.

Storage model

Shelley stores all conversations in a single SQLite DB at ~/.config/shelley/shelley.db (conversations + messages tables). This is structurally identical to Zed, so the parser and sync wiring mirror the Zed single-DB pattern: a virtual source path (shelley.db#<conversationID>), classification before the pathExists guard, a WAL/SHM composite mtime, per-session forceReplace on parse, and a SourceMtime branch for the live per-session watcher. No ResyncAll oldFileSessions accounting is needed (that path is specific to the OpenCode-format storage agents) — Shelley re-parses fresh from the present DB, and vanished conversations are preserved via the generic orphan-copy.

Extraction

messages.llm_data is a serialized llm.Message with PascalCase keys and integer enums. The content Type integers start at 2 (text=2, thinking=3, tool_use=5, tool_result=6) because ContentType shares an iota const block with MessageRole and iota does not reset between the two groups; this was verified against a real shelley.db. Tool results are stored as user-role messages (Anthropic-style) and pair into the originating tool call. All generations are included, ordered by sequence_id (monotonic and unique per conversation), so a context-reset / compaction boundary never hides earlier history.

Tokens & cost

usage_data already uses the canonical Anthropic token keys, so the raw blob is stored verbatim and cost is catalog-priced. Token usage is also captured on errored assistant turns (stored as type="error"). The exact gateway cost_usd is present in the payload but is currently 0 from the exe.dev gateway; capturing it without double-counting the catalog-priced per-message tokens is left as a follow-up.

Where to look

  • internal/parser/shelley.go — parser, virtual-path / read-only store helpers, content and token extraction.
  • internal/sync/engine.go — the Shelley sites mirroring Zed (classify, composite mtime, processShelley, dispatch, SourceMtime, cache-skip).
  • internal/parser/types.go — the AgentShelley registry entry.

Limitations

  • Exact cost_usd capture is deferred (see above); standard gateway models are priced correctly by the catalog.
  • Validated against real shelley.db data; tool-name categories cover the current Shelley tool set (bash, patch, keyword_search, browser*, subagent, …).

mjacobs added 3 commits June 16, 2026 22:54
Shelley stores every conversation in a single SQLite DB at
~/.config/shelley/shelley.db (conversations + messages tables). It is a
single-DB SQLite agent structurally identical to Zed, so it mirrors the
Zed sync wiring: a virtual source path (shelley.db#<conversationID>),
classify-before-pathExists, a WAL/SHM composite mtime, and per-session
forceReplace on parse.

messages.llm_data is a serialized llm.Message with PascalCase keys and
integer Role/Content-type enums (the one parsing gotcha); usage_data
already uses the canonical Anthropic token keys, so the raw blob is
stored verbatim for cost pricing. All generations are included ordered
by sequence_id, which is monotonic per conversation, so a context-reset
(distillation) boundary never hides earlier history.
- SourceMtime: add a Shelley branch so virtual shelley.db#<id> paths
  resolve to the conversation updated_at instead of falling through to
  os.Stat (which returns 0 and makes the live per-session watcher treat
  the source as gone). Adds parser.ShelleySourceMtime.
- Capture token usage on errored assistant turns (stored as
  type="error"), not just type="agent"; applyShelleyUsage now no-ops
  on the all-zero usage blobs Shelley writes for user/tool rows.
- Note that exact gateway cost_usd is available but deferred (capturing
  it without double-counting catalog-priced tokens needs a usage-event
  path); standard models price correctly via the catalog today.
- Tests: SourceMtime virtual-path resolution, real space-separated
  DATETIME format, and robustness (unknown content type, redacted
  thinking, malformed llm_data, errored-turn token capture).
- Frontend: distinct color/label for Shelley sessions.
Verified against a real shelley.db: content Type integers are
text=2, thinking=3, tool_use=5, tool_result=6 (not 0/1/3/4). In the
upstream llm package, ContentType shares a const block with MessageRole
(User=0, Assistant=1) and iota does not reset between the two groups, so
the content types start at 2. The unit fixtures previously used the same
wrong values as the parser, so they passed while disagreeing with
reality; they are now corrected too.
@roborev-ci

roborev-ci Bot commented Jun 17, 2026

Copy link
Copy Markdown

roborev: Combined Review (fb6dcbd)

Medium issue found: Shelley web-search tool results can lose content during parsing.

Medium

  • internal/parser/shelley.go:504 - shelleyToolResultText ignores Shelley web-search result blocks that carry Title/URL instead of Text. As a result, web_search_tool_result entries can be stored with empty result content, and the carrier message may then be filtered out.
    • Fix: handle shelleyContentWebSearchResult blocks in shelleyToolResultText, preserving at least title and URL, and add a test for nested web-search tool results.

Panel: ci_default_security | Synthesis: codex, 10s | Members: codex_default (codex/default, done, 10m33s), codex_security (codex/security, done, 1m52s) | Total: 12m35s

Shelley web_search_tool_result blocks nest their results as
web_search_result blocks (content type 9) that carry Title/URL instead
of Text. shelleyToolResultText only extracted Text, so these results
flattened to an empty string and the carrier message could be dropped.

Handle Title/URL blocks in shelleyToolResultText, join flattened blocks
with newlines so multiple results stay readable, and add a test covering
a nested web_search_tool_result.
@mjacobs

mjacobs commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in cd9223f.

shelleyToolResultText now handles web_search_result blocks (content type 9), which carry Title/URL instead of Text; previously they flattened to an empty string, so a web_search_tool_result could be stored empty and its carrier message dropped. Flattened blocks are now newline-joined so multi-result searches stay readable, and TestParseShelleyWebSearchToolResult covers a server-side web_search call plus its nested result blocks. Verified against the upstream llm.Content struct (web-search payload lives in Title/URL/EncryptedContent, never Text).

Context worth flagging: I caught and cleared this locally before pushing, because as of this session I finally have a matching roborev configuration — a two-reviewer codex (GPT-5.5) panel (correctness + security) mirroring ci_default_security. Until now my local loop wasn't apples-to-apples with CI, so this fix doubled as the first real end-to-end validation of that setup. Both passes came back clean on the fix commit.

@roborev-ci

roborev-ci Bot commented Jun 17, 2026

Copy link
Copy Markdown

roborev: Combined Review (cd9223f)

Reviewed Shelley changes: one medium correctness issue remains; no security findings.

Medium

  • internal/sync/engine.go:4875 - Shelley per-session skip detection uses updated_at as the only change signal, but Shelley timestamps appear to have one-second precision. Rapid in-place message rewrites within the same second can leave storedMtime == meta.FileMtime, causing a changed conversation to be skipped even though the shared Shelley DB file changed.
    Fix: Include a higher-resolution or content-based per-conversation change signal, or bypass the per-conversation skip when the shared Shelley DB mtime advanced but updated_at did not.

Panel: ci_default_security | Synthesis: codex, 11s | Members: codex_default (codex/default, done, 15m51s), codex_security (codex/security, done, 2m33s) | Total: 18m35s

Shelley's updated_at is SQLite CURRENT_TIMESTAMP (whole-second
precision), so keying per-conversation skip detection on it alone could
skip a conversation rewritten within the same wall-clock second even
though its content changed. The mirrored Zed parser is unaffected: Zed
writes sub-second RFC3339 timestamps, so its skip signal is already
collision-free.

Fold Shelley's own monotonic max(sequence_id) cursor -- the signal that
powers Shelley's incremental-fetch protocol -- into the File.Mtime change
signal as updatedAtNanos + maxSeq. All three signal sites (stored
File.Mtime, the meta skip query, and ShelleySourceMtime) share the
shelleyChangeMtime helper so they stay consistent. No engine, schema, or
Zed change is needed and File.Mtime drift stays sub-millisecond.
@roborev-ci

roborev-ci Bot commented Jun 17, 2026

Copy link
Copy Markdown

roborev: Combined Review (1166120)

Medium-risk issues remain in Shelley change detection and parse-diff integration.

Medium

  • internal/parser/shelley.go:204 and internal/sync/engine.go:4875
    The Shelley skip signal is only updated_at + MAX(sequence_id). This catches same-second appends, but not same-second in-place message rewrites where updated_at stays in the same SQLite CURRENT_TIMESTAMP second and sequence_id does not change. Those rows will be skipped and the stored transcript can remain stale.
    Fix: Include a rewrite-sensitive signal in the per-conversation comparison, such as a content/change hash or DB/WAL composite mtime fallback, and add a same-second in-place rewrite test.

  • internal/sync/parsediff.go:339
    stripVirtualSourceSuffix was not updated for Shelley virtual paths. Parse-diff stores Shelley sessions under shelley.db#id, but successfully parsed jobs are keyed by the real shelley.db, so missing/emission failures for stored Shelley sessions are reported as skipped/source-missing instead of parse drift, and per-session parse errors cannot be matched to stored rows.
    Fix: Add parser.ParseShelleyVirtualPath to stripVirtualSourceSuffix and cover parse-diff presence/error handling for Shelley.


Panel: ci_default_security | Synthesis: codex, 12s | Members: codex_default (codex/default, done, 7m36s), codex_security (codex/security, done, 1m30s) | Total: 9m18s

Fold a per-conversation content byte-length sum into the Shelley change
signal so the sync skip detects an in-place message rewrite that keeps
updated_at in the same wall-clock second and appends no new row
(sequence_id unchanged). The meta query computes the sum in SQLite via
LENGTH(CAST(... AS BLOB)); the parse loop sums the same bytes it already
reads, so both sides match exactly.

Add ParseShelleyVirtualPath to stripVirtualSourceSuffix so stored
shelley.db#id rows map back to the real shelley.db. Without it, a DB read
failure or dropped session for a stored Shelley conversation was reported
as source-missing instead of parse drift.
@roborev-ci

roborev-ci Bot commented Jun 17, 2026

Copy link
Copy Markdown

roborev: Combined Review (a47b340)

Findings need changes before merge.

Medium

  • internal/parser/shelley.go:195 - The Shelley skip signal only includes updated_at, max sequence_id, and total payload byte length, so a same-second in-place rewrite that preserves byte length can produce the same FileMtime and be skipped as unchanged. Include a real per-conversation content fingerprint in the stored/queried skip state, or disable the per-conversation skip path when Shelley's second-precision timestamp cannot distinguish rewrites.

Panel: ci_default_security | Synthesis: codex, 7s | Members: codex_default (codex/default, done, 9m0s), codex_security (codex/security, done, 51s) | Total: 9m58s

wesm added 2 commits June 17, 2026 07:51
Fold the Shelley change signal's max sequence_id and content-byte-length
into a sub-second offset via an FNV-1a hash rather than adding them to
the timestamp. A plain additive sum let the two cancel (a +1 sequence_id
paired with a -1 byte delta produced an unchanged signal), so the sync
skip could still miss a same-second append-plus-shrink. Folding the hash
into [0, 1s) also keeps file_mtime within the conversation's reported
second, so it stays a valid nanosecond timestamp for the range queries
in ListSessionsModifiedBetween.
Replace the additive/byte-length Shelley change signal with two
per-conversation signals so the sync skip cannot miss a rewrite:

- file_mtime stays the conversation's real updated_at timestamp. It must
  remain a true timestamp because ListSessionsModifiedBetween filters
  file_mtime <= now for PG/DuckDB push; a synthetic future value dropped
  a just-synced Shelley row from a same-second push until a later run.
- file_hash holds a content digest over the conversation's messages,
  computed identically by the meta skip query and the parse loop. It is
  length-framed, so it detects appends, in-place rewrites, and
  length-preserving same-second edits that a byte-length signal misses.

The bulk skip now compares the stored file_hash via GetFileHashByPath
alongside file_mtime. Computing the digest reads message payloads, which
the sub-second-timestamped siblings (Zed, Kiro) avoid; Shelley's
second-precision updated_at makes the read the price of not silently
skipping a rewrite. ShelleySourceMtime keeps a watcher-only change signal
(updated_at plus a sub-second digest term), compared for inequality and
never range-filtered.
@roborev-ci

roborev-ci Bot commented Jun 17, 2026

Copy link
Copy Markdown

roborev: Combined Review (31bbe02)

Summary verdict: One medium correctness issue remains; no high or critical findings were reported.

Medium

  • internal/parser/shelley.go:227 - The Shelley skip fingerprint omits parser-dependent metadata and message fields. It hashes sequence_id, llm_data, user_data, and usage_data, but parsed output also depends on fields such as slug, cwd, parent fields, model, created_at, message type, and message created_at. Because Shelley timestamps are only second-precision, same-second metadata-only changes can be skipped, leaving stale session names, projects, relationships, roles, or message timestamps indefinitely.

    Fix: Include every parser-dependent conversation and message field in the fingerprint used by ListShelleyConversationMetas, loadShelleyMessages, and ShelleySourceMtime.


Panel: ci_default_security | Synthesis: codex, 7s | Members: codex_default (codex/default, done, 5m6s), codex_security (codex/security, done, 2m4s) | Total: 7m17s

@roborev-ci

roborev-ci Bot commented Jun 17, 2026

Copy link
Copy Markdown

roborev: Combined Review (e0b062f)

Summary verdict: Two medium issues need attention; no high or critical findings were reported.

Medium

  • Location: internal/parser/shelley.go:253
    Problem: The Shelley skip fingerprint only hashes sequence_id, llm_data, user_data, and usage_data, but the parser also uses message type/created_at and conversation metadata such as slug, cwd, parent_conversation_id, user_initiated, and model. Same-second changes to any of those fields leave both file_mtime and file_hash unchanged, so processShelley skips the conversation and keeps stale session metadata/messages.
    Fix: Include every parser-observed conversation/message field in the Shelley fingerprint, and use the same digest inputs in ListShelleyConversationMetas, loadShelleyMessages, and ShelleySourceMtime.

  • Location: internal/postgres/push.go:1114
    Problem: Shelley now detects length-preserving in-place rewrites locally via file_hash, but PostgreSQL push can still skip replacing the messages because its fast path compares only message count and content-length aggregates. A Shelley rewrite from "answer aaaa" to "answer bbbb" is selected for push, then pushMessages returns without updating PG, leaving pg serve stale.
    Fix: Use an exact ordered content fingerprint for the PG push fast path, or otherwise force message replacement when the local session change was detected only through the Shelley/content hash.


Panel: ci_default_security | Synthesis: codex, 9s | Members: codex_default (codex/default, done, 5m28s), codex_security (codex/security, done, 1m17s) | Total: 6m54s

@roborev-ci

roborev-ci Bot commented Jun 17, 2026

Copy link
Copy Markdown

roborev: Combined Review (64cb7a2)

High-level verdict: two correctness issues remain; no security findings were reported.

High

  • internal/parser/shelley.go:473: COALESCE(user_initiated, 1) is scanned directly into a Go bool in the conversation load and metadata scan paths. SQLite expression columns are returned as integer values here, so Scan can fail before Shelley conversations parse or sync.
    • Fix: Scan user_initiated into an int/int64 or nullable integer in both queries, then convert with != 0.

Medium

  • internal/postgres/push.go:1193: The new PostgreSQL fast-path exact hash only covers messages.content. A Shelley same-second rewrite that changes only thinking_text/has_thinking updates local SQLite and is selected for push, but the fast path can still return early because content, counts, tokens, tools, and usage are unchanged, leaving PostgreSQL stale.
    • Fix: Include message flags/thinking text, and preferably role/timestamp, in the local/PG fast-path fingerprints, or force message replacement when the session-level change was caused by parser-visible fields not covered by the current message fingerprints.

Panel: ci_default_security | Synthesis: codex, 11s | Members: codex_default (codex/default, done, 6m33s), codex_security (codex/security, done, 1m57s) | Total: 8m41s

@roborev-ci

roborev-ci Bot commented Jun 17, 2026

Copy link
Copy Markdown

roborev: Combined Review (0c8cc8c)

Summary verdict: one medium correctness issue remains; no security issues were found.

Medium

  • internal/parser/shelley.go:621decodeShelleyMessage drops empty-content rows before applying usage_data, so a Shelley error/usage-only row with nonzero tokens but no displayable text/tool blocks is discarded and session token/cost totals are undercounted.
    • Fix: Apply usage before the empty-message return and preserve nonzero usage-only rows as system metadata, or route that usage into a dedicated aggregate/event path. Add a regression test for nonzero usage_data with empty or malformed llm_data.

Panel: ci_default_security | Synthesis: codex, 11s | Members: codex_default (codex/default, done, 12m42s), codex_security (codex/security, done, 2m21s) | Total: 15m14s

@roborev-ci

roborev-ci Bot commented Jun 17, 2026

Copy link
Copy Markdown

roborev: Combined Review (d2edf09)

No issues found.


Panel: ci_default_security | Synthesis: codex | Members: codex_default (codex/default, done, 6m44s), codex_security (codex/security, done, 1m23s) | Total: 8m7s

@wesm
wesm merged commit 6234eb6 into kenn-io:main Jun 17, 2026
15 checks passed
@mjacobs
mjacobs deleted the feat/shelley branch June 17, 2026 23:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants